Skip to content

[#824] Answer each batchRequest of a SOAP body with its own batchResponse - #836

Merged
vharseko merged 7 commits into
OpenIdentityPlatform:masterfrom
vharseko:issues/824-dsml-batchresponse-per-batchrequest
Aug 5, 2026
Merged

[#824] Answer each batchRequest of a SOAP body with its own batchResponse#836
vharseko merged 7 commits into
OpenIdentityPlatform:masterfrom
vharseko:issues/824-dsml-batchresponse-per-batchrequest

Conversation

@vharseko

@vharseko vharseko commented Aug 3, 2026

Copy link
Copy Markdown
Member

Fixes #824.

DSMLServlet.doPost() built a single BatchResponse before walking the SOAP body: the response elements of every batchRequest were merged into one reply and the requestID was overwritten on each iteration, so a body holding two batch requests was answered with one batchResponse carrying the requestID of the last one and the elements of both.

  • Each batchRequest of the SOAP body is now answered with a batchResponse of its own, marshalled into a document of its own (a DOM document has a single root element) and added to the same SOAP reply body.
  • The upfront BatchResponse remains for the errors detected before the body is walked (credentials, unparseable XML, unusable Content-Type) and for a body without any batchRequest: those replies are unchanged.
  • A batchRequest which fails schema validation is answered inside its own batchResponse under its own requestID, read from the element itself: the SAX fallback would recover the requestID of the first batchRequest of the body instead.
  • The error rejecting the elements over the ldap.dsml.batchrequests.max cap joins the last answered batchResponse instead of forming a root of its own: DSMLv2 expects a single batchResponse per SOAP body, and under the default cap of one a separate root would give every default deployment a two-root reply. Multi-root replies only appear once the cap is deliberately raised.
  • The DSMLv2 schema is now on the test classpath: the war packages it under WEB-INF/classes/resources, so in unit tests schema was silently null and validation never ran.

DSMLServletTestCase gains five cases: each batch request keeps its response elements and requestID to itself, a malformed batch request is answered under its own requestID while the valid one still runs, a malformed batch request without a requestID is answered without one, a non-batchRequest element of the body is answered in place as a malformed request, and an empty SOAP body still gets a single empty batchResponse. The excess-elements test pins the single-root shape of the default reply. All 21 tests of the class pass, and the war still carries the schema.

createSocket() has been binding the new client socket to the target server
address instead of connecting to it since OpenIdentityPlatform#279, so every plain or StartTLS
connection made through org.opends.server.tools.LDAPConnection fails with
"Address already in use" (server on the same host) or "Cannot assign
requested address" (remote server). Affects the DSML gateway, stop-ds,
manage-account and the other tools built on LDAPConnectionArgumentParser.
… on missing Content-Type

performLDAPRequest() returns null for an abandon request, but doPost()
dereferenced the result unconditionally, so a batch containing
<abandonRequest/> ended in a NullPointerException; as the connection was
closed after the loop instead of in a finally, one LDAP connection was
leaked per request.

messageFactory was only assigned when a SOAP 1.1 or SOAP 1.2 Content-Type
header was present, and was then dereferenced both when parsing the request
and when sending the response: a POST without Content-Type ended in a
NullPointerException, and, when an error response had already been queued,
in an empty HTTP 200 instead of that error. A missing or unsupported
Content-Type is now answered with a malformedRequest batch response.

Also log the failure instead of printing the stack trace when the response
cannot be sent, and add regression tests for both defects.
Report the failure to send the response to the container log: the
java.util.logging record was dropped, as connectToHost() resets the
LogManager and turns the root logger off on every non-verbose
connection. This needs super.init(config), without which
getServletContext() throws.

Drop the authzid of the previous batch request before setting the new
one: the connection options are shared by the whole SOAP body and
addSASLProperty() appends to the values of a key, so a second
authRequest made SASL PLAIN reject a multi-valued authzid. Now that the
connection is never reused, make it a loop local and remove the dead
null check that guarded the reuse.

Build the malformed Content-Type response with
createXMLParsingErrorResponse(), like the other two malformed paths, so
that the requestID is recovered; and keep reading the headers after a
malformed Authorization one, so that the reply keeps the SOAP version of
the request.

Cover the SOAP 1.2 path, the per-batch-request connection and the
authzid, let the fake LDAP endpoint serve several connections and fail
the test on a server-side error, and pin the createSocket() regression
of OpenIdentityPlatform#279 with a test in the module that owns it.
…uest of a SOAP body

The connection options are built once per doPost() and shared by all the batch
requests of the SOAP body, but the authzid was dropped only when the next batch
request carried an authRequest of its own. A body whose first batch request asks
for an authorization identity and whose second does not left the first authzid in
the options, so the operations of the second one ran under an identity the request
never asked for. It is gated on ldap.authzidtypeisid=true, which the shipped
web.xml leaves at false, and still subject to the proxied-auth privileges of the
server. The clearing now happens at the top of every iteration, before the
authRequest is looked at.

DSMLServletTestCase records the authorization identity of every SASL bind at the
fake endpoint: the existing test now asserts the identities themselves instead of
the mere absence of an error, and a new one pins the mixed body, where the second
batch request must bind with no authzid at all.

The four remaining Logger.getLogger(PKG_NAME) calls are replaced by
getServletContext().log(), so the class has a single logging sink: they were dead
for the reason already documented for the response path, which moves to the class
javadoc.
…th its own batchResponse

A single BatchResponse was shared by every batchRequest of the SOAP
body: the elements of all batch requests were merged into one reply and
the requestID was overwritten on each iteration. Marshal one
batchResponse per batchRequest into the SOAP body, keep the upfront
instance for the errors detected before the body is walked, and answer
a batchRequest which fails schema validation under its own requestID.
The DSMLv2 schema is now on the test classpath: without it the
unmarshaller silently skips validation.
@vharseko vharseko added bug java Pull requests that update java code labels Aug 3, 2026
@vharseko
vharseko requested a review from maximthomas August 3, 2026 17:54
@vharseko vharseko added the tests Test suites: fixing, enabling, un-disabling label Aug 3, 2026
@maximthomas

Copy link
Copy Markdown
Contributor

@vharseko please fix merge conficts

@vharseko

vharseko commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@vharseko please fix merge conficts

fixed

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is right and the attribution improvements are real — I A/B'd this branch against master through a dozen SOAP bodies and every misattribution case (alien element reported under the valid request's requestID, malformed-then-valid overwriting the ID, two couldNotConnect errors merged under the last ID) is fixed, with the conformant single-batchRequest body unchanged. The pom.xml change is a real fix too: schema was null in tests, so JAXB's declared-type unmarshal would have accepted any element as a BatchRequest. All 19 tests pass locally, and the war still carries WEB-INF/classes/resources/DSMLv2.xsd.

One change before merge.

A default deployment now answers with two batchResponse roots (medium)

ldap.dsml.batchrequests.max defaults to 1, so the cap error is reached by the second element of any body — and it now gets a root of its own. Same request (searchRequest batch + one excess batch), default config:

<!-- master -->
<batchResponse requestID="1"><searchResponse>…</searchResponse><errorResponse type="notAttempted">…</errorResponse></batchResponse>

<!-- this branch -->
<batchResponse requestID="1"><searchResponse>…</searchResponse></batchResponse>
<batchResponse><errorResponse type="notAttempted">…</errorResponse></batchResponse>

DSMLv2 §6 says "Each SOAP response body contains a single batchResponse", and opendj-dsml-servlet/resources/webapp/web.xml already says as much for the request side. A client reading "the batchResponse" now takes the first one and silently loses the notAttempted error it used to see — or fails to deserialize a two-root body outright. This happens out of the box, without anyone raising the cap.

Suggest keeping the cap error inside the last answered batchResponse in opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/DSMLServlet.java, so multi-root replies only appear once an admin deliberately raises the cap past what the spec describes:

if ( ++batchRequestCount > maxBatchRequests ) {
  BatchResponse capResponse = responses.isEmpty()
      ? objFactory.createBatchResponse() : responses.get(responses.size() - 1);
  capResponse.getBatchResponses().add(createErrorResponse(objFactory, …));
  if ( responses.isEmpty() ) { responses.add(capResponse); }
  break;
}

If you'd rather keep the separate root, it should at least carry the requestID of the element that tripped the cap — obj is already a SOAPElement at that point, so ((SOAPElement) obj).getAttribute("requestID") is in hand — plus a web.xml note. #844 keeps the requestID "for correlation" when it rejects an over-cap batch; the two caps should report the same way.

The shape change is not asserted anywhere (low)

testExcessBatchRequestsAreRejectedByDefault in opendj-dsml-servlet/src/test/java/org/opends/dsml/protocol/DSMLServletTestCase.java only checks response.contains("notAttempted"), so the one behaviour change a default deployment sees is unpinned. Whichever shape you settle on, assert the root count there with the new batchResponsesOf() helper.

Nits

  • Duplicated error builder: the JAXBException branch re-implements the tail of createXMLParsingErrorResponse(). Extract createMalformedRequestError(objFactory, message) and call it from both.
  • Naming: batchResponse/batchResponses (upfront) next to elementResponse/elementResponses/responses is easy to misread — renaming the upfront pair to prologueResponse/prologueResponses would make a wrong .add() visibly wrong.
  • Implicit invariant: the responses.isEmpty() fallback silently drops the upfront errors if both lists are ever populated. Unreachable today only because soapBody is assigned under batchResponses.isEmpty(). if ( !batchResponses.isEmpty() || responses.isEmpty() ) { responses.add(0, batchResponse); } is robust to future edits at no cost.
  • Untested branches: malformed element with no requestID (the isEmpty() ? null path) and a non-batchRequest element in the body — both behave correctly today, neither is covered.
  • Stale description: #811 is merged, so the "Stacked on #811 … I will rebase" paragraph can go.
  • Heads-up: #844 touches the same region of doPost(); whichever lands second needs a trivial rebase.

…hRequest cap is exceeded

The cap on batchRequest elements per SOAP body defaults to one, so the
separate batchResponse root for the over-cap error handed every default
deployment a two-root reply; DSMLv2 expects a single batchResponse per
SOAP body. The error now joins the last answered batchResponse, and the
excess-elements test pins the root count.

Also addressed from review: the malformedRequest builder is shared
between the JAXB and SAX paths, the upfront batchResponse pair is
renamed prologueResponse* so a wrong add() reads wrong, the prologue
fallback keeps its errors even if both response lists are ever
populated, and the previously uncovered branches (malformed element
without a requestID, non-batchRequest element in the body) are pinned
by tests.
@vharseko

vharseko commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

@maximthomas thanks for the thorough review — all points addressed in 33ac6d6.

Two-root reply under the default cap (medium): took your first suggestion. The over-cap error now joins the last answered batchResponse, so a default deployment answers with a single root again, exactly as on master; multi-root replies only appear once the cap is deliberately raised. One deviation from your snippet: the responses.isEmpty() fallback in it is unreachable — the cap is validated positive at servlet init, so at least one element is always answered before the cap can be exceeded — so the code appends unconditionally and a comment states the invariant.

Shape not asserted (low): testExcessBatchRequestsAreRejectedByDefault now pins it with batchResponsesOf() — a single root with requestID="1" holding exactly searchResponse + errorResponse type="notAttempted".

Nits, all taken:

  • extracted createMalformedRequestError(); both the JAXB branch and createXMLParsingErrorResponse() use it;
  • the upfront pair is renamed prologueResponse/prologueResponses;
  • the fallback is now !prologueResponses.isEmpty() || responses.isEmpty() with add(0, …), so prologue errors survive any future edit that populates both lists;
  • two new tests: a malformed batchRequest without a requestID is answered without one (not with an empty attribute), and a non-batchRequest element (<somethingElse xmlns="urn:example:not-dsml" requestID="7"/>) is answered in place as malformedRequest under its own requestID, without reaching the directory server;
  • the PR description is updated: the stale "stacked on [#809] Fix DSML gateway NPE on abandonRequest and on missing Content-Type #811" paragraph is gone and the cap behaviour is described.

All 21 tests of DSMLServletTestCase pass. On #844: agreed, whichever lands second takes the trivial rebase.

@vharseko
vharseko requested a review from maximthomas August 4, 2026 14:34
@vharseko
vharseko merged commit 6c0a88c into OpenIdentityPlatform:master Aug 5, 2026
17 checks passed
@vharseko
vharseko deleted the issues/824-dsml-batchresponse-per-batchrequest branch August 5, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug java Pull requests that update java code tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DSML gateway merges every batchRequest of a SOAP body into one batchResponse

2 participants